← Writeups

Single-endpoint race conditions

Race condition de un solo endpoint: dos solicitudes de cambio de email en paralelo compiten por el único registro pendiente y el token de una dirección termina en el correo de la otra, permitiendo apropiarse de la invitación de admin.

Target Overview & Vulnerability Summary

El objetivo es actualizar el email propio hacia carlos@ginandjuice.shop, que posee una invitación de admin sin reclamar. La funcionalidad de cambio de email guarda un único email pendiente por usuario y envía el enlace de confirmación de forma asíncrona. Disparando dos cambios en paralelo sobre el mismo endpoint, el token generado para una dirección se envía en el correo de la otra: la ventana entre "iniciar el envío" y "leer el email pendiente" permite el cruce de tokens.

Campo Valor
Severidad Alta (escalada a admin por apropiación de confirmación / broken access control)
Endpoint vulnerable POST /my-account/change-email
Root cause Estado mutable compartido (un único email pendiente por usuario) leído/escrito sin atomicidad en un flujo asíncrono

Technical Breakdown & Exploitation Flow

1. Observación del flujo legítimo

Con el exploit server como cliente de correo, un cambio de email normal produce:

Please click the link in your email to confirm the change of e-mail to:
anything@exploit0a6f00e8036ad3f1809e0c4a0138008aexploit-server.net

Y entrega un enlace de confirmación:

https://0a9d000f0344d3ed80df0de400a3002e.web-security-academy.net/confirm-email?user=wiener&token=7gidkYCg2PFLZvH8

Al enviar un segundo cambio de email y probar el enlace anterior, la respuesta es "This link is invalid.": el sistema almacena un único email pendiente a la vez — el nuevo cambio invalida el token anterior.

2. Solicitudes en paralelo

Enviando dos requests POST /my-account/change-email en paralelo (single-packet attack, grupo de Repeater), se reciben dos enlaces con el mismo timestamp y el mismo token:

https://0a9d000f0344d3ed80df0de400a3002e.web-security-academy.net/confirm-email?user=wiener&token=uk55BhkkCexOIIU6
https://0a9d000f0344d3ed80df0de400a3002e.web-security-academy.net/confirm-email?user=wiener&token=uk55BhkkCexOIIU6

El token es único por "generación", pero al correr ambas requests en la misma ventana, el token calculado en el envío A se reutiliza para el envío B porque la lectura del email pendiente no es coherente con el estado que cada request escribió.

3. Apropiación de la confirmación

Para el objetivo final, las dos direcciones en disputa son la propia (wiener@exploit-...) y carlos@ginandjuice.shop:

  1. Enviar en paralelo POST /my-account/change-email para ambas direcciones.
  2. Una de las requests escribe pending_email = carlos@ginandjuice.shop mientras la otra ya leyó el email previo y envió el token a la dirección de wiener.
  3. El enlace recibido en el correo de wiener contiene el token del email pendiente carlos@ginandjuice.shop (o viceversa).
  4. Visitar /confirm-email?user=wiener&token=<TOKEN> confirma el cambio hacia carlos@ginandjuice.shop.
  5. Con el email de la organización confirmado, reclamar la invitación de admin.

Root Cause Analysis

El patrón vulnerable:

# POST /my-account/change-email
if valid_session():
    db.pending_email[user] = request.email   # ❌ Sobrescribe un único registro global
    token = generate_token(user)
    async_send_email(user)                   # ❌ Tarea asíncrona

# Worker que envía el correo
def async_send_email(user):
    email = db.pending_email[user]           # ❌ Lee estado mutable compartido
    send_confirmation(email, token)

El fallo es la dependencia de un registro mutable compartido reutilizado entre requests concurrentes sin aislamiento transaccional: la tarea asíncrona lee pending_email en un momento distinto al que la request lo escribió. El token se genera para un valor de email, pero el worker resuelve el email de destino desde el estado global, ya sobrescrito por la request concurrente.

Secure Remediation

Registro independiente e inmutable por petición, con el token ligado al email de destino en el propio registro:

# POST /my-account/change-email
if valid_session():
    token = generate_token(user)
    db.store_pending_change({
        user: user,
        email: request.email,
        token: token
    })                                     # Registro propio, no sobrescribible
    send_confirmation(request.email, token) # Usa el valor inmutable de la request
  • Guardar cada cambio pendiente como una fila con su propio token, en lugar de un campo por usuario; la confirmación valida que el token pertenezca al email que lo generó.
  • Invalidar tokens anteriores de forma atómica dentro de la misma transacción que crea el nuevo.
  • SELECT ... FOR UPDATE sobre el registro del usuario si se mantiene un único pendiente, para serializar los cambios.